Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 92b84f0e3d6a720050b77165b9e4d1ba2cd78799


Parents : 2fe14ac
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-13T18:28:52-05:00

feat(rncp): implement receive completed notification handling and error reporting for file transfers

Changes

2 files changed, 338 insertions(+), 22 deletions(-)


Diff

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 44ba8e0d..bb680ebd 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -990,6 +990,27 @@ export default {
GlobalEmitter.emit("identity-switched", json);
break;
}
+ case "rncp.receive.completed": {
+ if (this.$route?.name !== "rncp") {
+ const detail =
+ json.status === "completed" && json.saved_path
+ ? json.saved_path
+ : json.error || json.status || "";
+ if (json.status === "completed") {
+ ToastUtils.success(
+ `${this.$t("rncp.received_file")}${detail ? ": " + detail : ""}`,
+ );
+ if (ElectronUtils.isElectron()) {
+ ElectronUtils.showNotification(this.$t("rncp.received_file"), detail || "");
+ }
+ } else {
+ ToastUtils.error(
+ `${this.$t("rncp.receive_failed")}${detail ? ": " + detail : ""}`,
+ );
+ }
+ }
+ break;
+ }
}
},
async getAppInfo() {

diff --git a/meshchatx/src/frontend/components/rncp/RNCPPage.vue b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
index 22466a0d..c6e153a0 100644
--- a/meshchatx/src/frontend/components/rncp/RNCPPage.vue
+++ b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
@@ -95,12 +95,29 @@
</div>
<div>
<label class="glass-label">{{ $t("rncp.file_path") }}</label>
- <input
- v-model="sendFilePath"
- type="text"
- placeholder="/path/to/file"
- class="input-field"
- />
+ <div class="flex gap-2">
+ <input
+ v-model="sendFilePath"
+ type="text"
+ placeholder="/path/to/file"
+ class="input-field flex-1 min-w-0"
+ />
+ <input
+ ref="sendFileInput"
+ type="file"
+ class="hidden"
+ @change="onWebSendFilePicked"
+ />
+ <button
+ type="button"
+ class="secondary-chip px-3 py-2 text-xs shrink-0"
+ :title="$t('rncp.browse_file')"
+ @click="pickSendFile"
+ >
+ <MaterialDesignIcon icon-name="folder-open-outline" class="w-4 h-4" />
+ {{ $t("rncp.browse_file") }}
+ </button>
+ </div>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
@@ -153,14 +170,22 @@
</div>
<div
v-if="sendResult"
- class="p-3 rounded-lg"
+ class="p-3 rounded-lg space-y-2"
:class="
sendResult.success
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'
"
>
- {{ sendResult.message }}
+ <div>{{ sendResult.message }}</div>
+ <div v-if="sendResult.success && sendResult.filePath" class="font-mono text-xs break-all">
+ {{ sendResult.filePath }}
+ </div>
+ <div v-if="sendResult.success && sendResult.filePath" class="flex gap-2">
+ <button type="button" class="secondary-chip text-xs py-1 px-2" @click="openPathInOs(sendResult.filePath)">
+ {{ $t("rncp.show_in_folder") }}
+ </button>
+ </div>
</div>
</div>
@@ -188,12 +213,23 @@
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.save_path_optional") }}</label>
- <input
- v-model="fetchSavePath"
- type="text"
- :placeholder="$t('rncp.save_path_placeholder')"
- class="input-field"
- />
+ <div class="flex gap-2">
+ <input
+ v-model="fetchSavePath"
+ type="text"
+ :placeholder="$t('rncp.save_path_placeholder')"
+ class="input-field flex-1 min-w-0"
+ />
+ <button
+ type="button"
+ class="secondary-chip px-3 py-2 text-xs shrink-0"
+ :title="$t('rncp.browse_folder')"
+ @click="pickFetchSaveDirectory"
+ >
+ <MaterialDesignIcon icon-name="folder-open-outline" class="w-4 h-4" />
+ {{ $t("rncp.browse_folder") }}
+ </button>
+ </div>
</div>
<div>
<label class="glass-label">{{ $t("rncp.timeout_seconds") }}</label>
@@ -244,14 +280,22 @@
</div>
<div
v-if="fetchResult"
- class="p-3 rounded-lg"
+ class="p-3 rounded-lg space-y-2"
:class="
fetchResult.success
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'
"
>
- {{ fetchResult.message }}
+ <div>{{ fetchResult.message }}</div>
+ <div v-if="fetchResult.success && fetchResult.savedPath" class="font-mono text-xs break-all">
+ {{ fetchResult.savedPath }}
+ </div>
+ <div v-if="fetchResult.success && fetchResult.savedPath" class="flex gap-2">
+ <button type="button" class="secondary-chip text-xs py-1 px-2" @click="openPathInOs(fetchResult.savedPath)">
+ {{ $t("rncp.show_in_folder") }}
+ </button>
+ </div>
</div>
</div>
@@ -290,6 +334,59 @@
</label>
</div>
</div>
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("rncp.listening_active_background") }}
+ </p>
+ <div
+ v-if="receiveDirectory"
+ class="p-3 rounded-lg bg-slate-50 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 space-y-2"
+ >
+ <div class="text-xs font-semibold text-gray-600 dark:text-zinc-300">
+ {{ $t("rncp.receive_folder") }}
+ </div>
+ <div class="font-mono text-xs break-all text-gray-800 dark:text-zinc-200">
+ {{ receiveDirectory }}
+ </div>
+ <button
+ type="button"
+ class="secondary-chip text-xs py-1.5 px-2"
+ @click="openReceiveDirectory"
+ >
+ <MaterialDesignIcon icon-name="folder-open-outline" class="w-4 h-4" />
+ {{ $t("rncp.open_folder") }}
+ </button>
+ </div>
+ <div
+ v-if="lastReceiveEvent"
+ class="p-3 rounded-lg border space-y-2"
+ :class="
+ lastReceiveEvent.status === 'completed'
+ ? 'bg-green-50/80 dark:bg-green-900/15 border-green-200 dark:border-green-800'
+ : 'bg-amber-50/80 dark:bg-amber-900/15 border-amber-200 dark:border-amber-800'
+ "
+ >
+ <div class="text-sm font-semibold text-gray-800 dark:text-zinc-100">
+ {{
+ lastReceiveEvent.status === "completed"
+ ? $t("rncp.received_file")
+ : $t("rncp.receive_failed")
+ }}
+ </div>
+ <div v-if="lastReceiveEvent.saved_path" class="font-mono text-xs break-all">
+ {{ lastReceiveEvent.saved_path }}
+ </div>
+ <div v-if="lastReceiveEvent.error" class="text-xs text-red-600 dark:text-red-400">
+ {{ lastReceiveEvent.error }}
+ </div>
+ <button
+ v-if="lastReceiveEvent.saved_path"
+ type="button"
+ class="secondary-chip text-xs py-1 px-2"
+ @click="openPathInOs(lastReceiveEvent.saved_path)"
+ >
+ {{ $t("rncp.show_in_folder") }}
+ </button>
+ </div>
<div class="flex gap-2">
<button
v-if="!listenActive"
@@ -337,9 +434,13 @@
<script>
import DialogUtils from "../../js/DialogUtils";
+import ElectronUtils from "../../js/ElectronUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import MarkdownRenderer from "../../js/MarkdownRenderer";
+import ToastUtils from "../../js/ToastUtils";
+
+const RNCP_LISTEN_PREFS_KEY = "meshchatx.rncp.listenForm.v1";
export default {
name: "RNCPPage",
@@ -372,10 +473,29 @@ export default {
listenActive: false,
listenDestinationHash: null,
listenResult: null,
+ receiveDirectory: null,
+ lastReceiveEvent: null,
+ fetchTransferId: null,
};
},
+ watch: {
+ listenAllowedHashes() {
+ this.saveRncpListenPrefs();
+ },
+ listenFetchJail() {
+ this.saveRncpListenPrefs();
+ },
+ listenFetchAllowed() {
+ this.saveRncpListenPrefs();
+ },
+ listenAllowOverwrite() {
+ this.saveRncpListenPrefs();
+ },
+ },
mounted() {
WebSocketConnection.on("message", this.handleWebSocketMessage);
+ this.loadRncpListenPrefs();
+ this.syncListenerStatusFromServer();
},
beforeUnmount() {
WebSocketConnection.off("message", this.handleWebSocketMessage);
@@ -383,14 +503,170 @@ export default {
this.cancelFetch();
},
methods: {
+ loadRncpListenPrefs() {
+ try {
+ const raw = localStorage.getItem(RNCP_LISTEN_PREFS_KEY);
+ if (!raw) {
+ return;
+ }
+ const o = JSON.parse(raw);
+ if (typeof o.listenAllowedHashes === "string") {
+ this.listenAllowedHashes = o.listenAllowedHashes;
+ }
+ if (o.listenFetchJail != null) {
+ this.listenFetchJail = o.listenFetchJail;
+ }
+ if (typeof o.listenFetchAllowed === "boolean") {
+ this.listenFetchAllowed = o.listenFetchAllowed;
+ }
+ if (typeof o.listenAllowOverwrite === "boolean") {
+ this.listenAllowOverwrite = o.listenAllowOverwrite;
+ }
+ } catch {
+ // ignore invalid storage
+ }
+ },
+ saveRncpListenPrefs() {
+ try {
+ localStorage.setItem(
+ RNCP_LISTEN_PREFS_KEY,
+ JSON.stringify({
+ listenAllowedHashes: this.listenAllowedHashes,
+ listenFetchJail: this.listenFetchJail,
+ listenFetchAllowed: this.listenFetchAllowed,
+ listenAllowOverwrite: this.listenAllowOverwrite,
+ }),
+ );
+ } catch {
+ // ignore quota / private mode
+ }
+ },
+ async syncListenerStatusFromServer() {
+ try {
+ const response = await window.api.get("/api/v1/rncp/status");
+ const s = response.data;
+ this.receiveDirectory = s.receive_directory || null;
+ if (!s?.listening) {
+ return;
+ }
+ this.listenActive = true;
+ this.listenDestinationHash = s.destination_hash || null;
+ if (Array.isArray(s.allowed_hashes) && s.allowed_hashes.length) {
+ this.listenAllowedHashes = s.allowed_hashes.join("\n");
+ }
+ this.listenFetchAllowed = Boolean(s.fetch_allowed);
+ this.listenFetchJail = s.fetch_jail || null;
+ this.listenAllowOverwrite = Boolean(s.allow_overwrite);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ notifyRncp(title, body) {
+ const text = body || title;
+ ToastUtils.success(text);
+ if (ElectronUtils.isElectron()) {
+ ElectronUtils.showNotification(title, body || "");
+ }
+ },
+ notifyRncpError(title, body) {
+ ToastUtils.error(body || title);
+ if (ElectronUtils.isElectron()) {
+ ElectronUtils.showNotification(title, body || "", true);
+ }
+ },
+ async openPathInOs(filePath) {
+ if (!filePath) {
+ return;
+ }
+ const ok = await ElectronUtils.revealPathInFolderOrCopy(filePath, () =>
+ ToastUtils.success(this.$t("common.copied")),
+ );
+ if (!ok) {
+ DialogUtils.alert(filePath);
+ }
+ },
+ async openReceiveDirectory() {
+ if (!this.receiveDirectory) {
+ await this.syncListenerStatusFromServer();
+ }
+ if (!this.receiveDirectory) {
+ return;
+ }
+ const ok = await ElectronUtils.openDirectoryOrCopy(this.receiveDirectory, () =>
+ ToastUtils.success(this.$t("common.copied")),
+ );
+ if (!ok) {
+ DialogUtils.alert(this.receiveDirectory);
+ }
+ },
+ async pickSendFile() {
+ const p = await ElectronUtils.pickFile();
+ if (p) {
+ this.sendFilePath = p;
+ return;
+ }
+ this.$refs.sendFileInput?.click();
+ },
+ onWebSendFilePicked(event) {
+ const f = event.target.files?.[0];
+ event.target.value = "";
+ if (!f) return;
+ this.sendFilePath = f.name;
+ DialogUtils.alert(this.$t("rncp.web_path_hint"));
+ },
+ async pickFetchSaveDirectory() {
+ const p = await ElectronUtils.pickDirectory();
+ if (p) {
+ this.fetchSavePath = p;
+ return;
+ }
+ if (ElectronUtils.isElectron()) {
+ return;
+ }
+ const entered = await DialogUtils.prompt(this.$t("rncp.web_save_path_prompt"));
+ if (entered != null && String(entered).trim()) {
+ this.fetchSavePath = String(entered).trim();
+ }
+ },
handleWebSocketMessage(message) {
try {
const data = JSON.parse(message.data);
if (data.type === "rncp.transfer.progress") {
- if (data.transfer_id === this.sendTransferId) {
- this.sendProgress = data.progress;
- } else {
- this.fetchProgress = data.progress;
+ const tid = data.transfer_id;
+ const p = typeof data.progress === "number" ? data.progress : 0;
+ if (this.sendInProgress) {
+ if (!this.sendTransferId && tid) {
+ this.sendTransferId = tid;
+ }
+ if (tid && this.sendTransferId === tid) {
+ this.sendProgress = p;
+ }
+ } else if (this.fetchInProgress) {
+ if (!this.fetchTransferId && tid) {
+ this.fetchTransferId = tid;
+ }
+ if (tid && this.fetchTransferId === tid) {
+ this.fetchProgress = p;
+ }
+ }
+ return;
+ }
+ if (data.type === "rncp.receive.completed") {
+ this.lastReceiveEvent = {
+ status: data.status,
+ saved_path: data.saved_path,
+ error: data.error,
+ };
+ if (data.status === "completed" && data.saved_path) {
+ this.notifyRncp(
+ this.$t("rncp.received_file"),
+ data.saved_path,
+ );
+ } else if (data.status !== "completed") {
+ this.notifyRncpError(
+ this.$t("rncp.receive_failed"),
+ data.error || data.status,
+ );
}
}
} catch {
@@ -410,6 +686,7 @@ export default {
this.sendInProgress = true;
this.sendProgress = 0;
this.sendResult = null;
+ this.sendTransferId = null;
try {
const response = await window.api.post("/api/v1/rncp/send", {
@@ -421,10 +698,13 @@ export default {
this.sendTransferId = response.data.transfer_id;
this.sendProgress = 1;
+ const fp = response.data.file_path;
this.sendResult = {
success: true,
message: this.$t("rncp.file_sent_successfully", { id: response.data.transfer_id }),
+ filePath: fp,
};
+ this.notifyRncp(this.$t("rncp.send_complete"), fp || "");
} catch (e) {
console.error(e);
this.sendResult = {
@@ -452,6 +732,7 @@ export default {
this.fetchInProgress = true;
this.fetchProgress = 0;
this.fetchResult = null;
+ this.fetchTransferId = null;
try {
const response = await window.api.post("/api/v1/rncp/fetch", {
@@ -463,12 +744,15 @@ export default {
});
this.fetchProgress = 1;
+ const saved = response.data.file_path;
this.fetchResult = {
success: true,
message: this.$t("rncp.file_fetched_successfully", {
- path: response.data.file_path || "current directory",
+ path: saved || "current directory",
}),
+ savedPath: saved,
};
+ this.notifyRncp(this.$t("rncp.fetch_complete"), saved || "");
} catch (e) {
console.error(e);
this.fetchResult = {
@@ -510,6 +794,7 @@ export default {
success: true,
message: response.data.message,
};
+ this.saveRncpListenPrefs();
} catch (e) {
console.error(e);
this.listenResult = {
@@ -518,7 +803,17 @@ export default {
};
}
},
- stopListen() {
+ async stopListen() {
+ try {
+ await window.api.post("/api/v1/rncp/stop");
+ } catch (e) {
+ console.error(e);
+ this.listenResult = {
+ success: false,
+ message: e.response?.data?.message || this.$t("rncp.failed_to_stop_listener"),
+ };
+ return;
+ }
this.listenActive = false;
this.listenDestinationHash = null;
this.listenResult = null;


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────